Quick and Easy Standalone Webmaps Using GeoPandas
NNIP Idea Showcase
June 20, 2024
Adam Porr
Research & Data Officer
Mid-Ohio Regional Planning Commission
Abstract¶
Lots of great third-party platforms are available which allow users to create interactive webmaps to present spatial data including ArcGIS Online, Google Maps, and Mapbox. These services are appropriate for many use cases, however some situations may warrant a standalone webmap that is not reliant on a third party platform. Such situations might include budget constraints, special access control requirements, special user interface requirements, the desire to package the content or implement version control, or the need to access the webmap in an environment without internet access. In such cases, it is possible to develop a standalone webmap from scratch using frameworks such as Leaflet or OpenLayers, however this can be time consuming and it requires knowledge of Javascript programming and web server administration. Luckily, the excellent GeoPandas package for Python provides a means of producing basic webmaps that is more accessible to Python programmers and avoids much of the complexity and tedium of building the webmap from scratch. This presentation demonstrates how to automatically produce an interactive standalone webmap from U.S. Census data using GeoPandas. The workflow is implemented using Jupyter to allow for convenient prototyping the webmap prior to production. The presentation also covers how to make the webmap accessible to the public using GitHub Pages.
Attendee familiarity with Python, Jupyter, and GitHub is helpful but not required.
Agenda¶
- Motivation
- Required tools
- Example workflow overview
- Data preparation
- Demonstration of webmap prototyping and export
- Publishing the webmap using GitHub Pages
How to access the content from this presentation¶
All of the content presented today is publicly available in GitHub:
TBD
The slides are available directly from the following URL:
TBD
The slides are implemented using Reveal.js, which arranges slides in a 2D layout. Press PGDN to move to the next slide or PGUP to move to previous slide, or press ESC to see an overview and move through the slides non-linearly.
The webmap is available directly from the following URL:
TBD
GeoPandas can make simple webmaps quickly and easily¶

Why would I want a standalone webmap?¶
- You can't afford to use a commercial service.
- You need to share a sensitive map with parties who cannot access a commercial service.
- You want the map to include **fancy features* that the commercial services don't support.
- You want to automate production of the map.
- You want to use revision control (e.g. git).
- You want to embed the map in a custom app more seamlessly
What are the steps?¶
- Set up the environment
- Prepare the data
- Prototype the webmap using Jupyter
- Export the webmap to a standalone HTML file
- Publish the HTML file on a webserver
Prerequisites¶
Demonstration¶
Prepare the environment¶
import pandas as pd
import geopandas as gpd
import requests
import json
import os
Prepare the data¶
Load tract geographies¶
tractsRaw = gpd.read_file("https://www2.census.gov/geo/tiger/TIGER2022/TRACT/tl_2022_39_tract.zip")
tracts = tractsRaw.loc[
(tractsRaw["STATEFP"] == '39') &
(tractsRaw["COUNTYFP"] == '049')
].copy() \
.filter(items=["GEOID","geometry"], axis="columns") \
.set_index("GEOID") \
.to_crs("epsg:3735")
tracts.head()
| geometry | |
|---|---|
| GEOID | |
| 39049006392 | POLYGON ((1812678.020 769514.607, 1812796.958 ... |
| 39049006500 | POLYGON ((1805161.518 730017.428, 1805222.134 ... |
| 39049006600 | POLYGON ((1806233.249 728073.229, 1806465.314 ... |
| 39049006710 | POLYGON ((1822275.804 759052.354, 1822275.413 ... |
| 39049006721 | POLYGON ((1818673.740 766164.975, 1818684.280 ... |
tracts.plot(figsize=(10,10))
<AxesSubplot:>
Load means of transportation by Census tract¶
commuteVars = {
"B08141_001E":"Workers 16 and over in households",
"B08141_002E":"No vehicles available",
"B08141_003E":"1 vehicle available",
"B08141_004E":"2 vehicles available",
"B08141_005E":"3 vehicles available",
"B08141_006E":"Drove alone",
"B08141_011E":"Carpooled",
"B08141_016E":"Public transportation",
"B08141_021E":"Walked",
"B08141_026E":"Commute by other means",
"B08141_035E":"Worked from home"
}
r = requests.get("https://api.census.gov/data/2022/acs/acs5?get=group(B08141)&ucgid=pseudo(0500000US39049$1400000)")
headers = r.json()[0]
commuteRaw = pd.DataFrame.from_records(r.json()[1:], columns=headers)
commute = commuteRaw.copy()
commute["GEOID"] = commute["GEO_ID"].apply(lambda x:x.split("US")[1])
commute = commute \
.set_index("GEOID") \
.filter(items=commuteVars.keys(), axis="columns") \
.rename(columns=commuteVars) \
.astype("int")
commute["No vehicles available (%)"] = commute["No vehicles available"].div(commute["Workers 16 and over in households"], fill_value=0).mul(100, fill_value=0)
commute.head()
| Workers 16 and over in households | No vehicles available | 1 vehicle available | 2 vehicles available | 3 vehicles available | Drove alone | Carpooled | Public transportation | Walked | Commute by other means | Worked from home | No vehicles available (%) | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| GEOID | ||||||||||||
| 39049000110 | 2273 | 6 | 580 | 1326 | 361 | 1516 | 65 | 20 | 15 | 198 | 81 | 0.263968 |
| 39049000120 | 1981 | 99 | 331 | 1152 | 399 | 1266 | 19 | 18 | 21 | 10 | 114 | 4.997476 |
| 39049000210 | 2029 | 122 | 538 | 883 | 486 | 1160 | 120 | 116 | 59 | 53 | 70 | 6.012814 |
| 39049000220 | 2293 | 0 | 455 | 1483 | 355 | 1420 | 201 | 50 | 17 | 103 | 63 | 0.000000 |
| 39049000310 | 1452 | 9 | 377 | 727 | 339 | 1067 | 159 | 7 | 0 | 16 | 26 | 0.619835 |
Load Central Ohio bikeways¶
firstTime = True
offset = 0
exceededLimit = True
recordCount = 2000
while exceededLimit:
print("Downloading records {} to {}".format(offset+1, offset + recordCount))
r = requests.get("https://services1.arcgis.com/EjjnBtwS9ivTGI8x/arcgis/rest/services/Bikeways_CentralOhio/FeatureServer/1/query?outFields=*&where=1%3D1&f=geojson&outSR=3735&resultOffset={}&resultRecordCount={}".format(offset, recordCount))
result = r.json()
temp = gpd.GeoDataFrame.from_features(result["features"], crs="epsg:3735")
if firstTime:
trailsRaw = temp.copy()
firstTime = False
else:
trailsRaw = pd.concat([trailsRaw, temp], axis="index")
offset += 2000
if "properties" in result:
if "exceededTransferLimit" in result["properties"]:
if result["properties"]["exceededTransferLimit"]:
exceededLimit = True
else:
exceededLimit = False
print("All records downloaded")
Downloading records 1 to 2000 Downloading records 2001 to 4000 Downloading records 4001 to 6000 Downloading records 6001 to 8000 Downloading records 8001 to 10000 All records downloaded
trails = trailsRaw.loc[trailsRaw["FacilityStatus"] != "REM"].copy() \
.filter(items=["FacilityStatus","FacilityType","geometry"]) \
.to_crs("epsg:3735")
trails["FacilityStatus"] = trails["FacilityStatus"].map({
"EX":"Existing",
"COM":"Committed",
"PRO":"Proposed",
"UND":"Under construction"
})
trails["FacilityType"] = trails["FacilityType"].map({
'PS': 'Paved Shoulder',
'RT': 'Signed Bicycle Route',
'SH': 'Shared Lane Markings',
'BB': 'Bicycle Boulevard',
'MBT': 'Mountain Bike Trail',
'STR': 'Street Crossing',
'PC': 'Pedestrian Connector',
'PATH': 'Multi-use Path',
'PT': 'Pedestrian Trail',
'PRO': 'Proposed',
'COM': 'Committed',
'NONE': 'No Connection',
'LANE': 'Bicycle Lane',
'PBL': 'Protected Bicycle Lane'
})
trails.head()
| FacilityStatus | FacilityType | geometry | |
|---|---|---|---|
| 0 | Proposed | Proposed | LINESTRING (1815340.339 887243.451, 1815290.44... |
| 1 | Proposed | Proposed | LINESTRING (1803621.977 887185.603, 1803652.13... |
| 2 | Proposed | Proposed | LINESTRING (1813076.868 887400.211, 1812935.86... |
| 3 | Proposed | Proposed | LINESTRING (1802465.467 886678.783, 1803540.35... |
| 4 | Proposed | Proposed | LINESTRING (1784373.787 886500.139, 1783966.76... |
ax = tracts.plot(color="gray", figsize=(15,15))
trails.plot(ax=ax, column="FacilityStatus", legend=True)
<AxesSubplot:>
Join means of transportation to tract geographies¶
tractsEnriched = tracts.join(commute)
tractsEnriched.head()
| geometry | Workers 16 and over in households | No vehicles available | 1 vehicle available | 2 vehicles available | 3 vehicles available | Drove alone | Carpooled | Public transportation | Walked | Commute by other means | Worked from home | No vehicles available (%) | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| GEOID | |||||||||||||
| 39049006392 | POLYGON ((1812678.020 769514.607, 1812796.958 ... | 2052 | 0 | 137 | 1208 | 707 | 1398 | 62 | 14 | 0 | 51 | 92 | 0.000000 |
| 39049006500 | POLYGON ((1805161.518 730017.428, 1805222.134 ... | 1597 | 4 | 225 | 1070 | 298 | 1125 | 64 | 0 | 11 | 0 | 85 | 0.250470 |
| 39049006600 | POLYGON ((1806233.249 728073.229, 1806465.314 ... | 2097 | 0 | 130 | 1311 | 656 | 1431 | 96 | 10 | 51 | 33 | 103 | 0.000000 |
| 39049006710 | POLYGON ((1822275.804 759052.354, 1822275.413 ... | 1469 | 16 | 312 | 853 | 288 | 1186 | 107 | 0 | 42 | 0 | 48 | 1.089176 |
| 39049006721 | POLYGON ((1818673.740 766164.975, 1818684.280 ... | 1893 | 0 | 185 | 1241 | 467 | 1482 | 44 | 0 | 54 | 21 | 40 | 0.000000 |
Prototype the webmap¶
Minimally configured¶
m = tractsEnriched.explore(column="No vehicles available")
trails.explore(m=m, column="FacilityStatus")